#!/usr/bin/env python

keep_history  = 100
alarm_seconds = 10
trash_dir     = "./.nsfm.trash"

__DEBUG__     = True
__DEBUG__     = False

# ##########################################################################
# TODO:
#    - use other dispatch systems (such a gnome)
#      (actually, this can be done fairly easily; just include a default rule
#       in the configuration file to call the external dispatcher; should
#       include an example in the sample configuration file)
#    - with a very long fltr, we overwrite the "Filter:" text
#    - proper handling of non-listing of entries (CVS, *.aux, etc)
#    - flexible specification of colors
#    - flexible specification of key bindings
#    - add INCLUDE directive for configuration file
#    - support for Gnome Terminal (and similar)
#    - file deletion, etc
#    - delete file/directory:
#       - document
#       - restore operation
#       - purge operation
#

# ##########################################################################
# standard modules

import os
import sys
import signal
import types
import commands
import curses
import string
import mimetypes
import getopt
import pickle
import time

# ##########################################################################
# non-standard modules

no_daemon_message = """\
unable to import the 'daemon' module; bailing out ...

nsfm depends on the python 'daemon' module; this is in the FreeBSD ports as
'devel/py-daemon', and on Ubuntu as the 'python-daemon' package; for sure
it is available on other systems and distributions too
"""

try:
   import daemon
except:
   sys.stderr.write( no_daemon_message );
   exit(1)

# ##########################################################################
# utilities

# miscellaneous

def identity(thing, ignored=None):
   return thing

def environ(key):
   try:
      return os.environ[key]
   except:
      return ""

def toggle(flags, f):
   if f in flags:
      return flags.strip(f)
   else:
      return flags + f

# files and directories

def dir_is_rx(path):
   return choose( os.path.isdir (path) and os.access(path, os.R_OK | os.X_OK), realpath(path))

def dir_is_rwx(path):
   return choose( os.path.isdir (path) and os.access(path, os.R_OK | os.W_OK | os.X_OK), realpath(path))

def file_is_r(path):
   return choose( os.path.isfile(path) and os.access(path, os.R_OK ), realpath(path))

def file_is_x(path):
   return choose( os.path.isfile(path) and os.access(path, os.X_OK ), realpath(path))

def getcwd():
   try:
      return os.getcwd()
   except:
      return ""

# paths

def basename(path):
   return os.path.basename(path)

def dirname(path):
   return os.path.dirname(path)

def expanduser(path):
   return os.path.expanduser(path)

def realpath(path):
   if not path or path[0] == "/":
      return path
   if path[0] == "~":
      return realpath(expanduser(path))
   return os.path.realpath(path)

paths = map( lambda(x): x.rstrip("/"), environ("PATH").split(":") )

def which(base, command=[]):
   if not base.strip():
      return [] # False
   if not command:
      command = base.split()
      return which(command[0], command)
   if base[0] == "/":
      return choose(file_is_x(base),command)
   if base.find("/") != -1:
      return [] # False (no relative paths)
   for path in map(lambda(x): "%s/%s" % (x,base), paths):
      if file_is_x(path):
	 return command # True
   return [] # False

def pretty_tilde(home, path):
   if path.startswith(home):
      return "~%s" % path[home_len:]
   else:
      return path

# strings

def lower(txt):
   return txt.lower()

def contains_upper(txt):
   return txt != lower(txt)

# lists, sets, bags

def union(a,b):
   return a + b

def flatten(bag, s=" "):
   return s.join(bag)

def non_null(bag):
   return filter(identity, bag)

def unique(bag):
   uniq = list()
   for b in bag:
      if not b in uniq:
	 uniq.append(b)
   return uniq

def with_index(bag):
   for i in range(len(bag)):
      yield i, bag[i]

def replace_all(bag, obj, rep):
   for i, e in with_index(bag):
      if e == obj:
	 bag[i] = rep
   return bag

# picking things
#
# choose() is analagous to other languages' conditional expressions:
#    - choose(a,b,c) is like (a ? b : c)
#    - except that all three expressions are evaluated
#
# if the condition is false and no third argument is provided, then choose()
# tries to return a null object of the correct type (that is, of the same type
# as b)
#
# select() is:
#    - a ? a : c

def choose(a,b,c=None):
   if a:
      return b
   if c != None:
      return c
   if type(b) == types.StringType: return ""
   if type(b) == types.ListType  : return []
   if type(b) == types.TupleType : return ()
   if type(b) == types.IntType   : return 0
   if type(b) == types.BooleanType  : return False
   return c

def select(a,c=None):
   return choose(a,a,c)

# slurping files

def slurp(filename):
   try:
      with open(filename, "r") as f:
	 return [ line.rstrip("\n") for line in f.readlines() ]
   except:
      return list()

def unslurp(filename, lines):
   try:
      with open(filename, "w") as f:
	 f.writelines( [ "%s\n" % line for line in lines ] )
      return True
   except:
      return False

# ##########################################################################
# paths

home = select(realpath("."))
home = select(realpath("~"), home)
home = select(realpath(environ("HOME")), home)
home = select(realpath(environ("NSFM_HOME")), home)

if not home:
   raise ValueError("unable to locate HOME directory")

home_len   = len(home)
visit_file = "%s/.nsfm.visited" % home
log_file   = "%s/.nsfm.log"     % home
help_file  = "%s/.nsfm.help"    % home
mark_file  = "%s/.nsfm.marks"   % home

CONF_FILES = [ "./nsfm.rc",
               "~/.nsfm.rc",
	       "/usr/local/etc/nsfm.rc",
	       "/etc/nsfm.rc" ]

if not __DEBUG__:
   log_file = "/dev/null"

# ##########################################################################
# logging/errors

def log(message, exit_code=0):
   if __DEBUG__:
      stamp = "%.2d:%d.%.2d" % time.localtime()[3:6]
      sys.stderr.write( "%s [%d]: %s\n" % (stamp, exit_code, str(message)) )
      sys.stderr.flush()
   if exit_code:
      exit(exit_code)

def die(message, exit_code=1):
   log(message, select(exit_code,1))

# ##########################################################################
# command execution

def runnable(command, original=[]):
   original = select(original, command)
   if command:
      if command[0] == "%t": return runnable(command[1:], original)
      if command[0] == "%h": return runnable(command[1:], original)
      if command[0] == "%f": return [] # False
      if command[0] == "%c": return [] # False
      return choose(which(command[0]), original)
   return [] # False

def system_command(command, self=None):
   log("system_command: %s" % str(command))
   curses.curs_set(1)
   curses.reset_shell_mode()
   exit_code = os.system( "clear; %s" % flatten(map(escape, command)) )
   curses.reset_prog_mode()
   curses.curs_set(0)

   if exit_code:
      log("Error %d:\n%s" % (exit_code,output))

   return select(self, exit_code)

def run_shell(self=None, ignored=None):
   return system_command(build_command(shell), self)

def run_command(command, self=None):
   log("run_command: %s" % str(command))

   exit_code, output = commands.getstatusoutput( flatten(map(escape, command)) )
   if exit_code:
      log("Error %d:\n%s" % (exit_code,output))

   return select(self, choose(exit_code, "", output))

def execute(command):
   log("execute: %s" % str(command))
   os.execvp(command[0], command)

def background(command, result=None):
   log("background: %s" % str(command))
   if not command:
      return result

   try:
      if os.fork() == 0:
	 with daemon.DaemonContext( working_directory=realpath(".") ):
	    execute(command)
	 die("exec failed")
   except:
      log("failed to fork")
   return result

# ##########################################################################
# usage

help_message = """\
nsfm -- quick help

normal mode summary:
   listing/ls    - normal mode, for selection of files and directories
   listing/dir   - like normal mode, but includes only directories

special mode summary:
   execute       - list and select commands for the currently-selected file
   history       - list and select previously-visited directories
                   (history is persistent across sessions)
   cdpath        - list and select directories found on the user's CDPATH
   tabs          - list and select directories visited in the current session
   jumps         - list and select available jumps

nsfm -- key binding summary:

   - "q" followed immediately by "q" again (within ten seconds)
     exit nsfm

   - [a-zA-Z0-9] + [ ".", "*", "&" ]
     incremental search:
       - alphanumerical characters match themselves
       - "." matches itself
       - "*" matches any sequence of characters
       - "&" restarts the search at the beginning of the text
       - so ".pdf&anua" matches "manual.pdf", as does ".pdf&a*a"

   - "#"
     clear any incremental search/filter

   - " ", "PAGE_DOWN" and "PAGE_UP", "DOWN_ARROW" and "UP_ARROW"
     page or move up or down within the current tab

   - "#" followed immediately by "#" again (within ten seconds)
     if in a non-listing tab, then return to the most recent listing tab
     otherwise, re-read the directory's contents

   - "~" jump to the user's home directory
   - "/" jump to the root directory

   - TAB (in listing modes, and in cdpath and history mode)
     if only one directory entry is selected, then jump to that directory
     otherwise, in listing mode, enter listing/dirs mode (in which files are
     supressed and only directories are displayed)

   - ENTER
     if the currently-selected thing is a directory, command, tab or jump,
       then go to or execute that thing
     if the currently-selected thing is a file, then execute the first command
       associated with that file
   - ";" (in listing/ls mode)
     present a list of commands for the currently-selected file

   - "[" move back to the previous tab
   - "]" move forward to the next tab

   - "=" enter history mode
   - "-" enter cdpath mode
   - "," enter tabs mode

   - "@" followed by any alphanumerical key k
     create a jump k to the current directory with the current filter
   - "'" followed by any alphanumerical key k
     jump to existing jump k
   - "'" followed immediately by "'" again (within ten seconds)
     enter jumps mode

   - DEL (in jumps and tabs mode)
     delete the currently-selected jump or tab

   - "$"
     open a command terminal (shell) in the current directory

   the main listing in listing modes is usually the output of "ls -l"
   - "^A" add or remove the "a" flag ("all") to/from the "ls" command
   - "^R" add or remove the "r" flag ("reverse") to/from the "ls" command
   - "^T" add or remove the "t" flag ("time order") to/from the "ls" command

   - "^V"
     call "vidir" or "vils" (if found) on all files/directories in the current
     directory matching the current incremental search/filter
     (unreliable)

   - "?"
     (attempt to) display this message in a terminal/pager window

nsfm requires a configuration file for its operation. It will start if it
cannot find its configuration file, but no operations on files will be
available; nsfm looks for its configuration file in the following locations:

CONF_FILES

For more information, please see the manual page and:
   - http://code.google.com/p/nsfm/
""" \
      .replace( "CONF_FILES", 
	         flatten( [ "\t%s" % c for c in CONF_FILES ], "\n" ) )

def output_help(exit_code=0, force_exit=False):
   choose(exit_code, sys.stderr, sys.stdout).write(help_message)
   if exit_code or force_exit:
      exit(exit_code)

unslurp( help_file, [ help_message ] )

usage_message = """\
usage:
   nsfm               (operate as file/directory browser)
   nsfm <filename>    (open file/directory browser with <filename> selected)
   nsfm <dirname>     (open file/directory browser in <directory>)
   nsfm -l <filename> (list operations for <filename>)
   nsfm -x <filename> (execute first operation for <filename>)
   nsfm -h            (display help, mainly on key bindings)
"""

def usage(message=""):
   if message:
      sys.stderr.write("error: %s\n" % message)
   sys.stderr.write(usage_message)
   exit(1)

# ##########################################################################
# command configuration

def running_in_tmux():
      return os.path.exists(environ("TMUX").split(",")[0]) and which("tmux")

# ################
# set the shell

shell = select(which("sh"))
shell = select(which("csh"), shell)
shell = select(which("tcsh"), shell)
shell = select(which("ksh"), shell)
shell = select(which("zsh"), shell)
shell = select(which("bash"), shell)
shell = select(which(environ("SHELL")), shell)
shell = select(which(environ("NSFM_SHELL")), shell)

if running_in_tmux():
   shell = select(which("tmuxshell"), shell)

if not shell:
   raise ValueError("unable to set the SHELL; please read the manual page (or set NSFM_SHELL)")

shell.insert(0, "%t")

# ################
# set the cdpath

cdpath = home
cdpath = select(environ("CDPATH"),      cdpath)
cdpath = select(environ("NSFM_CDPATH"), cdpath)
cdpath = unique( non_null ( [ dir_is_rx(os.path.realpath(path)) for path in cdpath.split(":") ] ) )

# ################
# set the editor

editor = [ "vi" ]
editor = select(which("emacs"), editor)
editor = select(which("vim"), editor)
editor = select(which(environ("EDITOR")), editor)

# ################
# set the pager

pager = [ "more" ]
pager = select(which("less"), pager)
pager = select(which(environ("PAGER")), pager)

# ################
# set the vidir

vidir = select(which("vils"))
vidir = select(which("vidir"), vidir)
vidir = select(which(environ("NSFM_VIDIR")), vidir)

# ################
# log these setings

log( "cwd:      %s" % getcwd() )
log( "home:     %s" % home     )
log( "shell:    %s" % shell    )
log( "cdpath:   %s" % cdpath   )
log( "editor:   %s" % editor   )
log( "pager:    %s" % pager    )
log( "vidir:    %s" % vidir    )

# ##########################################################################
# configuration file (mime configuration)

mime      = list()
conf_file = ( non_null( [ file_is_r(realpath(f)) for f in CONF_FILES ] ) + [ "" ] ) [0]

log("conf_file: <%s>" % conf_file )

for c in non_null( [ c.split("#")[0].split() for c in slurp(conf_file) ] ):
   if 3 <= len(c):
      mime.append(c)
   else:
      ValueError("unrecognised configuration line: %s (from %s)" % (str(c),conf_file) )

wildcard = "*"

def fixup_mime(entry):
   mime = [ choose(t != wildcard, t) for t in entry[0:2] ]
   for m in entry[2:]:
      if m == "PAGER":  mime.extend(pager);  continue
      if m == "EDITOR": mime.extend(editor); continue
      mime.append(m)
   return mime

mime = map(fixup_mime, mime)

# ##########################################################################
# constructing commands

def escape(s):
   for c in "\\() ;|&#<>":
      s = s.replace(c, "\\" + c )
   return s

# build_command() returns:
#    [ command_terms ]
# for example:
#    [ "xpdf", "filename.pdf" ]

def build_command(command, filename=""):
   log("build_comman: %s" % str(command))

   command = replace_all(runnable(list(command)), "%f", filename)

   if not command:
      return []

   if command[0] == "%h":
      command = [ "%t", "nsfm.hold" ] + command[1:]

   if command[0] == "%t":
      command = command[1:]

   return command

# ##########################################################################
# file type handling

def mime_match(mime, prefix):
   return prefix and mime.startswith(prefix)

def suffx_match(suffx, match):
   return match and suffx == match

mimetypes.init()

# mime_commands() returns:
#    [ ( [ command_terms ], command_text_list ) ]
# for example:
#    [ ( [ "xpdf", "filename.pdf" ], [ "xpdf", "%f" ] ), ( [ "acroread", "filename.pdf" ], [ "acroread", "%f" ] ) ]

def mime_commands(path):
   assert file_is_r(path)

   gmime = select(mimetypes.guess_type(path)[0], "")
   gfile = run_command([ "file", "-bi", path ]).split(";")[0]
   suffx = basename(path).split(".").pop()

   if gmime != gfile:
      log("mime:\n\t%s\n\t%s\n\t%s" % (path, gmime, gfile))

   commands = unique( [ c[2:]
	 		for c
			in mime
			if    suffx_match(suffx, c[0])
			   or mime_match (gmime, c[1])
			   or mime_match (gfile, c[1])
			   or not (c[0] + c[1]) ] )

   commands = [ (build_command(c, path), c) for c in commands         ]
   commands = [ c                           for c in commands if c[0] ]
   return commands

def escaped_mime_commands(path):
   return [ flatten(map(escape, command[0]))
	       for command
	       in mime_commands(path) ]

# ##########################################################################
# direct command listing and execution (optional, alternative use)

# l filename - list commands
# x filename - execute a command (in the background)

args = sys.argv[1:]

if args and args[0].startswith("-") and args[0] != "--":
   flag = args[0]
   args = args[1:]
   
   if flag == "-h":
      output_help(0, True) # dies

   if flag == "-l" or flag == "-x":
      if not len(args) == 1:
	 usage("exactly one filename argument required") # dies
      path = args[0]
      if not file_is_r(path):
	 usage("%s is not a readable file" % path) # dies

   if flag == "-l":
      unslurp("/dev/stdout", reduce(union, map(escaped_mime_commands, [ path ]), []))
      exit(0)

   if flag == "-x":
      commands = mime_commands(path)
      if commands:
	 execute(commands[0][0])
      else:
	 usage("no command available for %s" % path) # dies
      exit(0)

   usage() # dies

if args and args[0] == "--":
   args = args[1:]

if args:
   log("args[0] = %s" % args[0])
   if 1 < len(args):
      usage("at most one filename/directory argument allowed") # dies
   if not file_is_r(args[0]) and not dir_is_rx(args[0]):
      usage("file/directory not usable: %s" % args[0]) # dies
else:
   args.append(".")

assert len(args) == 1

# ##########################################################################
# keep track of working directories visited
#
# TODO: if two instances of nsfm are running, one will overwrite the history of
#       the other
#       this needs fixing ...

class History:

   exclude = frozenset( [ "/", home ] )

   def __init__(self):
      self.visited = self.read()
      self.visit(".")

   def visit(self, path):
      path = realpath(path)
      if dir_is_rx(path) and not path in History.exclude:
	 self.visited.insert(0, path)
      return self

   def read(self):
      return [ path for path in slurp(visit_file) if dir_is_rx(path) ]

   def write(self):
      return unslurp(visit_file, self.history())

   def history(self):
      self.visited = unique(self.visited)[:keep_history]
      return self.visited

   def chdir(self, path):
      try:
	 if dir_is_rx(path):
	    os.chdir(path)
	    self.visit(path)
	    return True
      except:
	 pass
      return False

   def clean_exit(self):
      log("self.visit: clean_exit")
      self.write()

# ##########################################################################
# keep track of marks

# TODO: the functionality here is split clumsily between 'class Marks' and
#       'class MARKS'; needs review

#  marks format:
#      (CWD, FLTR)
#
#  listing format:
#      (TEXT, CHARACTER, MARK_DATA)
#                        MARK_DATA = (CWD, FLTR)

class Mark:

   class State:
      NONE = 0
      MARK = 1
      JUMP = 2

   marks = {}
   state = State.NONE

   MARK_MARK_KEY = "@"
   MARK_JUMP_KEY = "'"
   MARK_KEYS     = MARK_MARK_KEY + MARK_JUMP_KEY

   def __init__(self):
      self.read_marks()

   def listing(self, cols):
      listing = []
      for chn in sorted(self.read_marks().keys()):
	 text  = "%s --> %s" % (chn, self.marks[chn][0]) + " " * cols
	 fltr  = self.marks[chn][1]
	 text  = text[0:cols-len(fltr)] + fltr
	 listing.append((text, chn, self.marks[chn]))
      return listing

   def read_marks(self):
      if file_is_r(mark_file):
	 try:
	    self.marks = pickle.load(open(mark_file, "r"))
	 except:
	    pass
      return self.marks

   def write_marks(self):
      try:
	 pickle.dump(self.marks, open(mark_file, "w"))
      except:
	 pass

   def markable(self, chn):
      return chn.isalnum()

   def marking(self, chn):
      if self.state != self.State.NONE:
	 if self.markable(chn) or chn in self.MARK_KEYS:
	    return True
	 self.state = self.State.NONE
      return False

   def start_mark(self, listing):
      self.state = self.State.MARK
      return listing.log("set mark [a-zA-Z0-9]")

   def start_jump(self, listing):
      self.state = self.State.JUMP
      return listing.log("jump to mark [a-zA-Z0-9]")

   def do_mark_jump(self, chn, listing):
      assert self.state != self.State.NONE
      state      = self.state
      self.state = self.State.NONE

      if not self.markable(chn):
	 if state == self.State.JUMP and chn == Mark.MARK_JUMP_KEY:
	    return listing.leave(MARKS(listing).ls(False)).log("entering marks")
	 else:
	    return listing.log("mark operation cancelled")

      assert self.markable(chn)

      if state == self.State.MARK:
	 self.read_marks()
	 self.marks[chn] = (listing.cwd, listing.fltr)
	 self.write_marks()
	 return listing.log("marked %s as \"%s\"" % (listing.cwd, chn))

      if state == self.State.JUMP:
	 self.read_marks()
	 if chn not in self.marks:
	    return listing.log("no such mark (\"%s\")" % chn)

	 assert self.marks[chn]

	 path = self.marks[chn][0]
	 fltr = self.marks[chn][1]

	 if listing.visit.chdir(path):
	    return listing.leave( LS(listing,fltr).ls() ).log("mark \"%s\": %s" % (chn,path))
	 else:
	    return self.log("%s does not seem to exist" % path)

      assert False

   def delete_mark(self, chn, listing):
      if not self.markable(chn):
	 return listing.log("\"%s\" is not markable, cannot delete" % chn)

      self.read_marks()
      if not chn in self.marks:
	 return listing.log("\"%s\" is not currently a mark" % chn )

      del self.marks[chn]
      self.write_marks()
      return listing.leave(MARKS(listing).ls(False)).log("\"%s\" deleted" % chn )

# ##########################################################################
# class hierarchy
#
# LINKED:
#       LISTING:
# 	    LS:
# 		  DIRS
#           TEMP:
# 	          DIRLIST:
# 		        HIST
# 		        CDPATH
# 	          EXEC
#                 TABS
#                 MARKS

# ##########################################################################
# linked list

class LINKED:

   def __init__(self, current):
      # link self to neighbours
      self.prev_node = current
      self.next_node = current.next_node if current else None

      # link neighbours to self
      if self.prev_node: self.prev_node.next_node = self
      if self.next_node: self.next_node.prev_node = self

   def remove_node(self,destination):
      if self.usable(destination):
	 if self.prev_node:
	    self.prev_node.next_node = self.next_node
	 if self.next_node:
	    self.next_node.prev_node = self.prev_node
	 return destination.activate()
      return self

   def predecessor(self):
      return select(self.prev_node, self)

   def successor(self):
      return select(self.next_node, self)

   def nodes(self):
      while self.next_node:
	 self = self.next_node
      while self:
	 this = self
	 self = self.prev_node
	 yield this

   def validate(self):
      if __DEBUG__:
	 seen_self = False
	 for node in self.nodes():
	    assert node
	    if node == self:
	       seen_self = True
	    if node.prev_node:
	       assert node.prev_node.next_node == node
	    if node.next_node:
	       assert node.next_node.prev_node == node
	 assert seen_self
      return self

   def clean_exit(self, exit_code=0):
      self.visit.clean_exit();
      log("clean_exit")
      system_command(["clear"])
      curses.reset_shell_mode()
      exit(0)

# ##########################################################################
# LISTING

class LISTING(LINKED):

   visit      = History()
   marks      = Mark()
   cache_dir  = {}
   message    = []

   # ##########################################################################
   # keyboard input handler

   handlers = \
      {
	 "q:::q"         : lambda(self): self.clean_exit(0),

	 "#"             : lambda(self): self.clear(),

	 "~"             : lambda(self): self.chdir(home).log("Gee, it's good to be back home again"),
	 "/"             : lambda(self): self.chdir("/"),
	 "\\"            : lambda(self): self.chdir(".."),

	 "KEY_DOWN"      : lambda(self): self.inc_skip(1),
	 "KEY_UP"        : lambda(self): self.dec_skip(1),

	 " "             : lambda(self): self.inc_skip(LISTING.screen_size[0]-1),
	 "KEY_NPAGE"     : lambda(self): self.inc_skip(LISTING.screen_size[0]-1),
	 "KEY_PPAGE"     : lambda(self): self.dec_skip(LISTING.screen_size[0]-1),

	  "="            : lambda(self): self.leave(HIST(self).ls(False)).log("entering history"),
          "-"            : lambda(self): self.leave(CDPATH(self).ls(False)).log("entering cdpath"),
	  ","            : lambda(self): self.leave(TABS(self).ls(False)).log("entering tabs"),

	 "$"             : lambda(self): run_shell(self).log(shell),

	 "["             : lambda(self): self.leave(self.predecessor()),
	 "]"             : lambda(self): self.leave(self.successor()),

	 "KEY_ENTER"     : lambda(self): self.enter(),

	 Mark.MARK_MARK_KEY
			 : lambda(self): self.marks.start_mark(self),
	 Mark.MARK_JUMP_KEY
			 : lambda(self): self.marks.start_jump(self),

 	 "?"             : lambda(self): background(
	                                    build_command(
					       [ "%t" ] + pager + [ help_file ] ),
					       self ),
      }

   # ###
   #

   def handle(self, chn, pchn, screen):

      LISTING.screen      = screen
      LISTING.screen_size = screen.screen_size()

      if chn == "q":
	 state = self.log("that's one \"q\", another within %d seconds will quit" % alarm_seconds)

      if self.marks.marking(chn):
	 return self.marks.do_mark_jump(chn,self)

      for code in [ "%s:::%s" % (pchn, chn), chn ]:
	 if code in self.handlers:
	    return self.handlers[code](self)

      if self.is_fltr_char(chn):
	 return self.clear( self.fltr + chn )

      if chn == "KEY_BACKSPACE":
	 return self.clear( self.fltr[:-1]  )

      return self

   # ###
   #

   def __init__(self, mode_name, current):
      LINKED.__init__(self,current)
      self.mode_name = mode_name                  # displayed top right
      self.cwd       = getcwd()                   # is always CWD
      self.where     = pretty_tilde(home, self.cwd) # displayed top left
      self.listing   = list()
      self.fltr      = ""
      self.skip      = 0

   # #############################################################

   # data:       the data associated with self.listing[self.skip + i]
   # text:       the displayed text for that line
   # filename:   that part of text/data representing the file name or command
   # match_text: that part of the text/filename/command against which filters operate

   def data(self, i=0):
      if self.valid(i):
         return self.listing[self.offset(i)]
      else:
	 raise ValueError("no text: %s" % str(i))

   def len       (self      ): return len(self.listing) - self.skip
   def offset    (self, i=0 ): return i + self.skip
   def valid     (self, i=0 ): return 0 <= i and i < self.len()
   def text      (self, i=0 ): return self.data(i)
   def filename  (self, i=0 ): return self.text(i)
   def match_text(self, i=0 ): return self.filename(i)

   def filter    (self      ): return " %d/%d \"%s\"" % (self.skip, len(self.listing), self.fltr)
   def accept    (self, f   ): return True
   def display   (self      ): return self.mode_name
   def context   (self      ): return self.mode_name

   # ###
   #

   def log(self,message=None):
      if message:
	 self.message.append(str(message))
      else:
	 del self.message[:]
      return self

   # ###
   #

   def clear(self, fltr=""):
      self.fltr, self.skip = fltr, 0
      return self.ls()

   # ###
   #

   def ls(self, cache=True):
      skip         = self.skip
      self.skip    = 0

      self.listing = self.cache(cache);

      self.skip    = min( [ skip,      len(self.listing) - 1 ] )
      self.skip    = max( [ self.skip, 0                     ] )
      return self

   # ###
   #

   def match_needles(self, haystack, needles):
      for needle in needles:
	 offset = haystack.find(needle)
	 if offset == -1:
	    return False
	 haystack = haystack[offset + len(needle):]
      return True

   def match_fltr(self, haystack, needles_list):
      for needles in needles_list:
	 if not self.match_needles(haystack, needles):
	    return False
      return True

   def cache(self, use_cache=True):
      fltr      = self.fltr
      context   = self.context()
      cache_dir = self.cache_dir

      if context in cache_dir and not use_cache:
	 del cache_dir[context]
	 self.log("cleared cache")

      if context not in cache_dir:
	 cache_dir[context] = {}

      if fltr in cache_dir[context]:
	 return cache_dir[context][fltr]

      if fltr[0:-1] in cache_dir[context]:
	 self.listing = cache_dir[context][fltr[0:-1]]
      else:
	 self.listing = self.raw_list()

      handle_caps  = choose(contains_upper(fltr), identity, lower)
      needles_list = [ ns.split("*") for ns in fltr.split("&") ]

      listing      = [ self.data(i)
		          for i in range(len(self.listing))
		          if ( self.match_fltr ( handle_caps( self.match_text(i) ), needles_list )
			          and self.accept( self.filename(i) ) ) ]

      cache_dir[context][fltr] = listing
      return listing

   # ###
   #

   def chdir(self, path):
      if self.visit.chdir(path):
	 return self.leave(LS(self).ls()).log("chdir %s" % path)
      else:
	 return self

   # ###
   #

   def usable(self, other):
      return other and other != self

   # ###
   #

   def panic(self):
      self.log("panic!")
      if self.usable(self.predecessor()):
	 return self.remove_node(self.predecessor())

      if self.usable(self.successor()):
	 return self.remove_node(self.successor())

      for alternative in [ home, "/" ]:
	 node = self.remove_node(self.chdir(alternative))
	 if self.usable(node):
	    return node

      raise ValueError("failed to activate() -- after much panic")

   def activate(self):
      if self.cwd and self.visit.chdir(self.cwd):
	 return self
      return self.panic().activate()

   # ###
   #

   def leave(self, destination):
      if self.usable(destination):
	 return destination.activate()
      return self

   # ###
   #

   def is_fltr_char(self,chn):
      return len(chn) == 1 and ( chn.isalnum() or chn in ".*&" )

   # ###
   #

   def inc_skip(self, s):
      self.skip += min( [ s, self.len() - 1 ] )
      return self

   def dec_skip(self, s):
      self.skip -= min( [ s, self.skip ] )
      return self

   # ###
   #

   def enter(self):
      if self.len():
	 filename = self.filename()
	 if dir_is_rx(filename):
	    return self.chdir(filename)
	 if file_is_r(filename):
	    return EXEC(filename, self).ls().enter()
      return self

# ##########################################################################
# LISTS - LS

class LS(LISTING):

   exclude        = frozenset( [ "CVS" , ".", ".." ] )
   exclude_tex    = frozenset( [ "aux", "log", "nav", "out", "snm", "toc", "deps" ] )
   exclude_suffix = frozenset( [ "~", "handout.tex", "handout.pdf" ] )

   handlers = dict( LISTING.handlers.items() +
      {
	  "#:::#"   : lambda(self): self.ls(False).log("re-read directory"),

	  "^V"      : lambda(self): self.vidir(),

	  "^A"      : lambda(self): self.set_ls_flags( toggle(self.ls_flags, "a") ).ls(),
	  "^R"      : lambda(self): self.set_ls_flags( toggle(self.ls_flags, "r") ).ls(),
	  "^T"      : lambda(self): self.set_ls_flags( toggle(self.ls_flags, "t") ).ls(),

	  ";"       : lambda(self): ( self.leave(EXEC(self.filename(), self).ls())
					 if self.len() and file_is_r(self.filename()) else self),

	  "KEY_DC"  : lambda(self): self.delete(),
	  "KEY_TAB" : lambda(self): ( self.enter()
					 if self.len() == 1 and dir_is_rx(self.filename())
					 else self.leave(DIRS(self).ls(False)) )
      }.items() )

   # ###
   #

   def __init__(self, current, fltr=""):
      LISTING.__init__(self, "listing/ls", current)
      self.ls_com   = "ls"
      self.ls_flags = "-l"
      self.fltr     = fltr

   # ###
   #

   def set_ls_flags(self, flags):
      self.ls_flags = flags
      return self

   # ###
   #

   def context(self):
      return "%s:%s:%s" % (self.mode_name, self.cwd, self.ls_flags)

   def display(self):
      return "%s [%s %s]" % (self.mode_name, self.ls_com, self.ls_flags)

   # ###
   #

   def extract_filename(self, text):
      return text.split(None, 8)[8] \
	        .split(" -> ")[0] # symbolic links

   def raw_list(self):
      return [ (text, self.extract_filename(text))
	          for text
	          in run_command([self.ls_com, self.ls_flags]).split("\n")[1:] ]

   # ###
   #

   def text(self, i=0):
      return self.data(i)[0]

   def filename(self, i=0):
      return self.data(i)[1]

   # ###
   #

   def exists(self, prefix, suffix):
      return file_is_r("%s.%s" % (prefix, suffix))

   def depend_exists(self, prefix, suffix, pre, post):
      return suffix == pre and self.exists(prefix, post)

   # ###
   #

   def accept(self, path):
      parts  = path.split(".")
      suffix = parts.pop()
      prefix = flatten(parts,".")

      if path in LS.exclude:
	 return False
	 
      if suffix in self.exclude_tex:
	 if self.exists(prefix, "tex"):
	    return False

      for suffix in self.exclude_suffix:
	 if path.endswith(suffix):
	    return False

      if self.depend_exists(prefix, suffix, "c",     "o"   ): return False
      if self.depend_exists(prefix, suffix, "ascii", "html"): return False

      return True

   # ###
   #

   def delete(self):
      if self.len():
	 filename = self.filename()
	 self.log("delete: %s" % filename)
	 try:
	    if not dir_is_rwx(trash_dir):
	       os.mkdir(trash_dir)
	 except:
	    return self.log("could not use/create %s" % trash_dir)
	 try:
	    os.rename(filename, "%s/%s" % (trash_dir,filename))
	 except:
	    return self.log("could not move to %s/%s" % (trash_dir,filename))
      return self.log("%s now in %s" % (filename,trash_dir)).ls(False)

   # ###
   #

   def vidir(self):
      if not vidir:
	 return self
      return system_command(
	        build_command( vidir + [ escape(self.filename(i)) for i in range(self.len()) ] ),
	        self )

# ##########################################################################
# LISTS - DIRS

class DIRS(LS):

   handlers = dict( LS.handlers.items() +
      {
	  "KEY_TAB" : lambda(self): ( self.enter()
					 if self.len() == 1 and dir_is_rx(self.filename())
					 else self )
      }.items() )

   def __init__(self, current):
      assert current;
      LS.__init__(self, current)
      self.mode_name = "listing/dirs"
      self.ls_flags  = current.ls_flags
      self.clear(current.fltr)

   def accept(self, path):
      return dir_is_rx(path)

# ##########################################################################
# LISTS - TEMP

class TEMP(LISTING):

   handlers = dict( LISTING.handlers.items() + 
      {
	 Mark.MARK_MARK_KEY
	                 : lambda(self): self.log("cannot mark in this mode (%s)" % self.mode_name),
	 "#:::#"         : lambda(self): self.leave(self.predecessor()),
      }.items() )
	 
   def __init__(self, mode_name, current):
      LISTING.__init__(self, mode_name, current)
      self.where = ""

   def leave(self, destination):
      return self.remove_node(destination)

# ##########################################################################
# LISTS - DIRLIST

class DIRLIST(TEMP):

   exclude = [ "/CVS" ]

   handlers = dict( TEMP.handlers.items() +
      {
	  "KEY_TAB" : lambda(self): ( self.enter()
					 if self.len() == 1 and dir_is_rx(self.filename())
					 else self ),
      }.items() )
	 
   # ###
   #

   def __init__(self, mode_name, current):
      TEMP.__init__(self, mode_name, current)

   def raw_list(self,listing):
      return [ ("cd %s" % path, path) for path in listing ]

   # ###
   #

   def text(self, i=0):
      return self.data(i)[0]

   def filename(self, i=0):
      return self.data(i)[1]

   # ###
   #

   def enter(self):
      if self.len():
	 path = self.filename()
	 if dir_is_rx(path):
	    return self.chdir(path)
      return self

   # ###
   #

   def accept(self, path):
      for exclude in DIRLIST.exclude:
	 if path.endswith(exclude):
	    return False
      return dir_is_rx(path)

# ##########################################################################
# LISTS - HIST

class HIST(DIRLIST):

#    handlers = dict( DIRLIST.handlers.items() +
#       {
#       }.items() )
	 
   def __init__(self, current):
      DIRLIST.__init__(self, "history/dirs", current)

   def raw_list(self):
      return DIRLIST.raw_list(self, LISTING.visit.history())

# ##########################################################################
# LISTS - CDPATH

class CDPATH(DIRLIST):

#    handlers = dict( DIRLIST.handlers.items() +
#       {
#       }.items() )
	 
   def __init__(self, current):
      DIRLIST.__init__(self, "cdpath/dirs", current)

   def raw_list(self):
      return DIRLIST.raw_list(self, reduce( union,
	                                    [ [ realpath("%s/%s" % (path, name))
			                           for name
			                              in os.listdir(path)
			                              if not name.startswith(".") ]
		                              for path in cdpath ],
					      [] ) )

   def match_text(self, i=0):
      return basename(self.filename(i))

# ##########################################################################
# LISTS - EXEC

class EXEC(TEMP):

   handlers = dict( TEMP.handlers.items() +
      {
      }.items() )
	 
   def __init__(self, target, current):
      TEMP.__init__(self, "commands", current)
      self.target = target
      self.where  = self.cwd

   # ###
   #

# raw list contains:
#    ( FLAT_COMMAND, ( LIST_COMMAND, MIME_PATTERN_LIST ) )
# for example:
#    ( "xpdf filename.pdf", ( [ "xpdf", "filename.pdf" ], [ "xpdf", "%f"] ) )

   def raw_list(self):
      return [ (flatten(command[1]), command)
	          for command in mime_commands(self.target) ]

   def context(self):
      return "%s:%s:%s" % (self.mode_name, self.cwd, self.target)

   # ###
   #

   def text(self, i=0):
      return "$ %s" % self.data(i)[0]

   def filename(self, i=0):
      return self.data(i)[0]

   def match_text(self, i=0):
      return flatten( self.data(i)[1][1] )

   # ###
   #

   def is_terminal_command(self):
      return self.data()[1][1][0] == "%t"

   def enter(self):
      if not self.len():
	 return self

      if not file_is_r(self.target): # ????
	 return self

      command = self.data()[1][0]

      if self.is_terminal_command():
	 system_command(command)
      else:
	 background(command)

      if self.usable(self.predecessor()):
	 return self.leave(self.predecessor())

      self.clean_exit(0)

   # ###
   #

   def validate(self):
      if __DEBUG__:
	 for data in self.listing:
	    assert len(data)  == 2
	    assert type(data) == types.TupleType
	 return LISTING.validate(self)
      return self

# ##########################################################################
# LISTS - TABS

class TABS(TEMP):

   # listing format:
   #    ( TEXT, NODE)

   handlers = dict( TEMP.handlers.items() +
      {
	  "KEY_TAB" : lambda(self): ( self.enter()
					 if self.len() == 1
					 else self ),
	  ","       : identity,
	  "KEY_DC"  : lambda(self): self.delete_tab(),
      }.items() )

   def __init__(self, current):
      TEMP.__init__(self, "tabs", current)

   def raw_list(self):
      listing = list()
      cols    = LISTING.screen_size[1]
      for node in self.nodes():
	 if self.usable(node):
	    text  = "%-12s %s" % ( node.mode_name, node.cwd ) + " " * cols
	    right = node.filter()
	    text  = text[0:cols-len(right)] + right
	    listing.append((text,node))
      return listing

   # ###
   #

   def text(self, i=0):
      return self.validate().data(i)[0]

   def filename(self, i=0):
      return self.data(i)[1].cwd

   # ###
   #

   def enter(self):
      if self.len():
	 return self.leave(self.data()[1])
      return self

   # ###
   #

   def validate(self):
      if __DEBUG__:
	 for data in self.listing:
	    assert len(data)  == 2
	    assert type(data) == types.TupleType
	 return LISTING.validate(self)
      return self

   # ###
   #

   def delete_tab(self):
      if not self.valid():
	 return self.log("no tab selected, cannot delete")
      node = self.data()[1]
      return node.remove_node(self).ls(False)

# ##########################################################################
# LISTS - MARKS

class MARKS(TEMP):

   handlers = dict( TEMP.handlers.items() +
      {
	 "KEY_DC" : lambda(self): ( self.marks.delete_mark(self.data()[1], self)
	                               if self.valid()
				       else self.log("no mark selected") ),
      }.items() )

   def __init__(self, current):
      TEMP.__init__(self, "marks", current)

   def raw_list(self):
      return self.marks.listing(self.screen_size[1])

   def text(self, i=0):
      return self.validate().data(i)[0]

   def match_text(self, i=0):
      return self.validate().data(i)[2][0]

   def enter(self):
      if self.len():
	 path = self.data()[2][0]
	 fltr = self.data()[2][1]
	 if self.visit.chdir(path):
	    return self.leave(LS(self,fltr).ls())
	 else:
	    return self.log("cannot chdir %s" % path)
      return self

# ##########################################################################
# main screen/curses class

class Curses_Screen:

   dir_text      = "CWD: "
   mess_text     = "Message: "
   curr_text     = "Current: "
   none_text     = "<none>"

   dir_text_len  = len(dir_text)
   mess_text_len = len(mess_text)
   curr_text_len = len(curr_text)

   def __init__(self,stdscr):
      curses.curs_set(0)
      self.init(stdscr)

   def re_init(self):
      lines, cols = self.stdscr.getmaxyx()
      if lines != self.lines + 5 or cols != self.cols + 1:
	 self.init(self.stdscr)

   def init(self,stdscr):
      self.stdscr           = stdscr
      self.lines, self.cols = stdscr.getmaxyx()

      if self.lines < 7:  # this number is accurate
	 raise ValueError("too few screen lines; nsfm requires at least 7 lines")
      if self.cols  < 20: # this number is arbitrary
	 raise ValueError("too few screen columns; nsfm requires at least 20 columns")

      # ###########################
      # colours

      curses.init_pair( 1, curses.COLOR_WHITE,  curses.COLOR_BLACK )
      curses.init_pair( 2, curses.COLOR_RED,    curses.COLOR_BLACK )
      curses.init_pair( 3, curses.COLOR_BLUE,   curses.COLOR_BLACK )
      curses.init_pair( 4, curses.COLOR_CYAN,   curses.COLOR_BLACK )
      curses.init_pair( 5, curses.COLOR_GREEN,  curses.COLOR_BLACK )

      self.col_self         = curses.color_pair(4)
      self.col_data         = curses.color_pair(2)
      self.col_curr         = curses.color_pair(5)

      # ###########################
      # windows

      self.wdir             = curses.newwin( 1,            self.cols, 0,            0 )
      self.wlinetop         = curses.newwin( 1,            self.cols, 1,            0 )
      self.wmainline        = curses.newwin( 1,            self.cols, 2,            0 )
      self.wmain            = curses.newwin( self.lines-6, self.cols, 3,            0 )
      self.wlinebot         = curses.newwin( 1,            self.cols, self.lines-3, 0 )
      self.wcurr            = curses.newwin( 1,            self.cols, self.lines-2, 0 )
      self.wmess            = curses.newwin( 1,            self.cols, self.lines-1, 0 )
      self.lines           -= 5 
      self.cols            -= 1

      self.windows = [ self.wdir,
		       self.wlinetop,
		       self.wlinebot,
		       self.wcurr,
		       self.wmess,
		       self.wmainline,
		       self.wmain ]

      # ###########################
      # borders and static text

      self.wdir.addnstr ( self.dir_text,  self.dir_text_len,  self.col_self )
      self.wcurr.addnstr( self.curr_text, self.curr_text_len, self.col_self )
      self.wmess.addnstr( self.mess_text, self.mess_text_len, self.col_self )

      self.wlinetop.attron( self.col_self )
      self.wlinebot.attron( self.col_self )

      for win in [ self.wlinetop, self.wlinebot ]:
	 for x in range(self.cols):
	    win.addch(curses.ACS_HLINE)

      # ###########################
      # keypad

      self.wmain.keypad(1)

      # ###########################
      # end of init():

   def screen_size(self):
      return (self.lines, self.cols)

   def refresh_win(self,win):
      win.clrtobot()
      win.refresh()

   def refresh_all(self):
      for w in self.windows:
	 self.refresh_win(w)

   def text_left(self, text, win, y, attr, offset):
      win.move(y,offset)
      win.addnstr( text, self.cols-offset, attr )
      win.clrtoeol()

   def text_right(self, text, win, y, attr):
      offset = max([ 0, self.cols - len(text) ])
      win.move(y, offset)
      win.addnstr( text, self.cols-offset, attr )

   def update_dir(self,state):
      self.text_left (state.where,             self.wdir,  0, self.col_data, self.dir_text_len)
      self.text_right(" %s" % state.display(), self.wdir,  0, self.col_self)

   def update_mess(self, state=None):
      self.text_left(flatten(state.message, "; "), self.wmess, 0, self.col_curr, self.mess_text_len)

   def update_curr(self,state):
      curr = state.filename() if state.len() else self.none_text
      fltr = state.filter()
      self.text_left (curr, self.wcurr, 0, self.col_curr, self.curr_text_len)
      self.text_right(fltr, self.wcurr, 0, self.col_curr)

   def update_main(self, state):
      for i in range(self.lines):
	 if i == 0:
	    # top line
	    if state.len():
	       self.text_left(state.text(),   self.wmainline, 0, self.col_curr, 0)
	    else:
	       self.text_left(self.none_text, self.wmainline, 0, self.col_self, 0)
	 else:
	    # all other lines
	    if i < state.len():
	       self.text_left(state.text(i), self.wmain, i-1, self.col_data, 0)
	    else:
	       if i:
		  self.text_left("~", self.wmain, i-1, self.col_self, 0)

   def getch(self):
      ch  = self.wmain.getch()
   
      chn = ( curses.keyname(ch)
	         if ch != curses.ERR
		 else "<KEY ERROR>" )

      if   ch  == 127  : chn = "KEY_BACKSPACE"
      elif chn == "^J" : chn = "KEY_ENTER"
      elif chn == "^I" : chn = "KEY_TAB"

      return ch, chn

   def display_and_getch(self, state):
      self.re_init()
      self.update_dir(state)
      self.update_mess(state)
      self.update_curr(state)
      self.update_main(state)
      self.redraw()

      return self.getch()

   def redraw(self):
      for w in self.windows:
	 w.redrawwin()
      self.refresh_all()

# ##########################################################################
# main

def main(stdscr):

   assert len(args) == 1

   path      = realpath(args[0])
   directory = choose(file_is_r(path), dirname(path), path)

   try:
      os.chdir(directory)
   except:
      die("cannot chdir %s" % directory)

   screen    = Curses_Screen(stdscr)
   ch,  chn  = -1, ""
   pch, pchn = -1, ""

   state     = ( EXEC(basename(path), None)
	            if file_is_r(path)
		    else LS(None) ).ls().validate()

   # #######################################
   # signals

   def clean_exit(signal=0, frame=0):
      state.clean_exit(signal)

   signal.signal( signal.SIGINT,  clean_exit )
   signal.signal( signal.SIGTSTP, run_shell  )
   signal.signal( signal.SIGALRM, identity   )

   # #######################################
   # main loop

   while True:

      signal.setitimer(signal.ITIMER_REAL, alarm_seconds, alarm_seconds)

      # pch:  previous ch  (previous input character)
      # pchn: previous chn (previous curses key name)

      pch, pchn = ch, chn
      ch,  chn  = screen.display_and_getch(state)

      state = state \
		 .log() \
		 .handle(chn, pchn, screen) \
		 .validate()

# ##########################################################################
# redirect stderr

if __DEBUG__:
   sys.stderr.write("note: any subsequent error messages are sent to %s\n" % log_file)
   sys.stderr.write("      if nsfm fails, please look there\n")

try:
   sys.stderr = open(log_file, 'w')
except:
   raise Exception, "failed to open %s" % log_file

# ##########################################################################
# call all of that ...

curses.wrapper(main)

